---
title: 单元测试
date: 2021-08-22 22:10:43
images: ['https://kodo.huixiangwuyou.com/blog/images/5246621314.png']
summary: '前端单元测试框架包含：Jest、Mocha、Jasmine、Vitest、React Testing Library。'
tags: 
 - web
---

当前主要了解 Jest 和 Vitest 框架


### Jest

[Jest](https://jestjs.io/zh-Hans/) 是 Facebook 开源的一个测试框架，它提供了一套非常简单易用的 API，可以用来测试 JavaScript 代码。Jest 的特点包括：
- 自动发现测试文件：Jest 可以自动发现测试文件，无需手动指定测试文件。
- 快速执行测试：Jest 使用并行执行测试，可以快速执行测试。
- 丰富的断言库：Jest 提供了一套丰富的断言库，可以方便地编写测试用例。
- 支持异步测试：Jest 支持异步测试，可以方便地测试异步代码。
- 支持快照测试：Jest 支持快照测试，可以方便地测试组件的渲染结果。

#### 安装与配置
```js
npm install --save-dev jest

// package.json
{
  "scripts": {
    "test": "jest"
  }
}

```

> 测试的文件名中必须包含 "test." 或 "spec." 。
> Jest 默认会查找以下类型的测试文件：在 __tests__ 文件夹中的 .js 文件,将要测试的文件复制一份到 __tests__ 文件夹,然后再测试文件直接引用

#### 常用API
- `describe`：用于描述测试套件。
- `test`：用于描述测试用例。
- `it`：用于描述测试用例。
- `expect`：用于断言。

#### 配置文件
```js
// jest.config.js
export default {
  verbose: true,
  roots: ['<rootDir>/src'],
  testMatch: [
    '/__tests__/**/*.[jt]s?(x)',
    '**/?(*.)+(spec|test).[jt]s?(x)'
  ],
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  // 转译设置 ，需要安装包依赖
  transform: {
    '^.+\\.(js|jsx)$': 'babel-jest',
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
  // extensionsToTreatAsEsm: [ '.mjs'],
  testPathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/build/',
    '/.next/'
  ],
  // 处理 TypeScript (如果需要)
  preset: 'ts-jest/presets/default-esm',
  globals: {
    'ts-jest': {
      useESM: true,
      tsconfig: 'tsconfig.esm.json'
    }
  }
  // 覆盖率
  collectCoverage: true,
  coverageDirectory: '__tests__/coverage',
  // 别名映射
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^pages/(.*)$': '<rootDir>/src/pages/$1',
    '^router/(.*)$': '<rootDir>/src/router/$1',
    '^widget/(.*)$': '<rootDir>/src/widget/$1',
    '^utils/(.*)$': '<rootDir>/src/utils/$1',
    '^~assets/(.*)$': '<rootDir>/src/assets/$1',
    '^config/(.*)$': '<rootDir>/src/config/$1',
    '^store/(.*)$': '<rootDir>/src/store/$1',
  }
};



```

#### 常用断言
- `toBe`：用于比较两个值是否相等。
- `toEqual`：用于比较两个对象是否相等。
- `toBeNull`：用于判断一个值是否为 null。

#### 常用匹配器

#### 常用钩子函数

```js
// Applies to all tests in this file
beforeEach(() => {
  return initializeCityDatabase();
});

test('city database has Vienna', () => {
  expect(isCity('Vienna')).toBeTruthy();
});

test('city database has San Juan', () => {
  expect(isCity('San Juan')).toBeTruthy();
});

describe('matching cities to foods', () => {
  // Applies only to tests in this describe block
  beforeEach(() => {
    return initializeFoodDatabase();
  });

  test('Vienna <3 veal', () => {
    expect(isValidCityFoodPair('Vienna', 'Wiener Schnitzel')).toBe(true);
  });

  test('San Juan <3 plantains', () => {
    expect(isValidCityFoodPair('San Juan', 'Mofongo')).toBe(true);
  });
});

// 执行周期
describe('describe outer', () => {
  console.log('describe outer-a');

  describe('describe inner 1', () => {
    console.log('describe inner 1');

    test('test 1', () => console.log('test 1'));
  });

  console.log('describe outer-b');

  test('test 2', () => console.log('test 2'));

  describe('describe inner 2', () => {
    console.log('describe inner 2');

    test('test 3', () => console.log('test 3'));
  });

  console.log('describe outer-c');
});

// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test 1
// test 2
// test 3
```

#### 常用快照测试
```js
import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});
```

#### Mock 模拟
1. 手动 Mock 
```js
// __mocks__/userApi.js
export const getUser = jest.fn(() => Promise.resolve({
  id: 1,
  name: 'Mock User'
}));

import { getUser } from '../api/userApi';
jest.mock('../api/userApi'); // 这会自动使用 __mocks__ 下的实现

test('should return mock user', async () => {
  const user = await getUser();
  expect(user.name).toBe('Mock User');
});
```
2. 内联 Mock
```js
const mockFn = jest.fn();
mockFn.mockReturnValue(42);

test('mock function', () => {
  expect(mockFn()).toBe(42);
});


jest.mock('axios', () => ({
  get: jest.fn(() => Promise.resolve({ data: 'mock data' }))
}));

import axios from 'axios';

test('mock axios', async () => {
  const response = await axios.get('/api');
  expect(response.data).toBe('mock data');
});
```
3. Mock 实现控制
```js
const mockFn = jest.fn();

// 设置返回值
mockFn.mockReturnValue('default')
     .mockReturnValueOnce('first call')
     .mockReturnValueOnce('second call');

// 设置实现
mockFn.mockImplementation(() => 'complex implementation');

// 设置 Promise 返回值
mockFn.mockResolvedValue('async value');
```
4. Mock 部分实现
```js
jest.mock('../module', () => {
  const originalModule = jest.requireActual('../module');
  
  return {
    ...originalModule,
    functionToMock: jest.fn()
  };
});
```
5. Mock 模块

```js
// api.js
export const fetchData = async () => {
  const response = await fetch('/api/data');
  return response.json();
};

// api.test.js
import { fetchData } from './api';

beforeAll(() => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ data: 'mock data' })
    })
  );
});

afterAll(() => {
  global.fetch.mockRestore();
});

test('fetchData returns mock data', async () => {
  const data = await fetchData();
  expect(data).toEqual({ data: 'mock data' });
  expect(global.fetch).toHaveBeenCalledWith('/api/data');
});
```


#### 测试用例
```
test('the data is peanut butter', async () => {
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

test('the fetch fails with an error', async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (error) {
    expect(error).toMatch('error');
  }
});

// 描述测试套件
describe('模块/组件名称', () => {
  // 测试前的准备工作
  beforeAll(() => {
    // 在所有测试之前执行一次
  });
  
  beforeEach(() => {
    // 在每个测试之前执行
  });
  
  // 单个测试用例
  test('应该...', () => {
    // 测试断言
    expect(...).toBe(...);
  });
  
  // 也可以用 it() 别名
  it('应该...', () => {
    // 测试断言
  });
  
  // 测试后的清理工作
  afterEach(() => {
    // 在每个测试之后执行
  });
  
  afterAll(() => {
    // 在所有测试之后执行一次
  });
});

```
[react 组件](https://jestjs.io/zh-Hans/docs/tutorial-react)使用
```js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Button from './Button';

describe('Button 组件', () => {
  test('渲染正确的内容', () => {
    render(<Button>点击我</Button>);
    expect(screen.getByText('点击我')).toBeInTheDocument();
  });
  
  test('点击事件触发', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>点击</Button>);
    
    fireEvent.click(screen.getByText('点击'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});
```

![测试用例](https://kodo.huixiangwuyou.com/blog/images/web/4250413215755.png)
![覆盖用例](https://kodo.huixiangwuyou.com/blog/images/web/3413220116.png)
<br/>
<br/>


### Vitest

[Vitest](https://vitest.dev/) 是一个基于 Vite 的测试框架，它提供了一套简单易用的 API，可以用来测试 JavaScript 代码。Vitest 的特点包括：
- 基于 Vite：Vitest 基于 Vite，可以充分利用 Vite 的性能优势。
- 支持异步测试：Vitest 支持异步测试，可以方便地测试异步代码。
- 支持快照测试：Vitest 支持快照测试，可以方便地测试组件的渲染结果。

#### 安装与配置
```js
npm install --save-dev vitest

// vite.config.js  若是使用了 vite ，用 /// <reference types="vitest" /> 引入vitest
// 若是没有使用 vite ，则直接在 package.json 中配置即可，或者新增 vitest.config.js 文件
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,// 启用全局API 
    // jsdom-浏览器环境模拟 安装 jsdom 包 ，happy-dom-被认为比 jsdom 更快 安装 happy-dom 包 
    // edge-runtime 模拟 Vercel 的 edge-runtime，使用 @edge-runtime/vm 包
    // 默认 node
    environment: 'happy-dom', 
    transformMode: {
      web: [/\.vue$/],
    },
    // 可选：指定测试文件的路径
    include:['test/**/*.test.js','test/**/*.spec.ts'],
    // 可选：指定测试文件的初始化文件
    exclude:['node_modules','dist','**/*.css',"src/**/*.vue","src/**/*.js","src/**/*.ts"],
    css: {
      modules: {
        classNameStrategy: 'non-scoped',
      },
    },
    // 可选：强制内联某些依赖 比如排除element-plus 不内联，避免出现 Unknown file extension “.css”
    deps: {
      inline: ['element-plus'],
    }
  },
})

// package.json
{
  "scripts": {
    "test": "vitest"
    // 或者
    "test": "vitest --config=vitest.config.js"
  }
}
```
#### 常用API
##### 配置文件
- `environment`：指定测试环境。
- `transformMode`：指定文件类型的转换模式。
- `setupFiles`：指定测试文件的初始化文件。
- `setupFilesAfterEnv`：指定测试文件的初始化文件。
- `globalSetup`：指定全局的初始化文件。
- `globalTeardown`：指定全局的清理文件。
- `testTimeout`：指定测试的超时时间。
- `coverage`：指定覆盖率报告的配置。
  - reporter:['text', 'json', 'html', 'lcov'],报告格式（支持多种）
  - provider：'istanbul'||'v8',指定覆盖率报告的提供者,需要下载指定的依赖。
  - reportsDirectory：'test/coverage',指定测试报告的配置。

> globals: true 全局使用vitest的 API，不需要引入，但是在ts文件需要额外配置

```js
确保安装了 Vitest 的类型定义：
npm install --save-dev @types/node @vitest/globals

// 在 compilerOptions.types 中添加 vitest/globals：
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  },
  "include": ["src", "test"],
  "exclude": ["node_modules"] 
}

```
##### 测试文件
[配置文档api](https://cn.vitest.dev/api/)
- `test`：定义一个测试用例,别名: it。
  - test.each：定义多个测试用例。
  - test.only：只执行当前测试用例。
  - test.skip：跳过当前测试用例。
  - test.skipIf:`test.skipIf(condition)( 'skipped test',fn)`,在条件下跳过当前测试用例。
  - test.todo:`test.todo('unimplemented test')` ,来存根测试，以便稍后实施,以便知道还有多少测试需要执行。
- `bench`：基准是定义一系列操作的函数。Vitest 会多次运行该函数，以显示不同的性能结果。
  - bench.skip
  - bench.only
  - bench.todo
- `it`：定义一个测试用例。
- `describe`：定义一个测试套件,用来包裹 test、bench、it方法，。
- `expect`：断言测试结果。
  - expect(value).toBe(value)：断言值是value。
  - expect(value).toEqual(value)：断言值是否相等。
  - expect(value).toBeTruthy()：断言值是否为真。
  - expect(value).toBeFalsy()：断言值是否为假。
  - expect(fn).toBeDefined()：断言fn值是否有返回值。
  - expect(value).toBeUndefined()：断言值是否没有返回值。
  - expect(value).toBeNull()：断言值是否为 null。
  - expect(value).toBeNaN()：断言值是否为 NaN。
  - expect(value).toBeGreaterThan(value)：断言值是否大于指定值。
  - expect(value).toBeLessThan(value)：断言值是否小于指定值。
  - expect(value).toBeGreaterThanOrEqual(value)：断言值是否大于等于指定值。
  - expect(value).toBeLessThanOrEqual(value)：断言值是否小于等于指定值。
  - expect(value).toContain(value)：断言值是否包含指定值,或者说是否在Dom元素内。
  - expect(value).toMatch(regexp)：断言值是否匹配指定的正则表达式。
- `assert`：断言测试结果,用法和 expect 差不多。  



#### 生命钩子
- `beforeEach`：在每个测试用例之前执行。
- `afterEach`：在每个测试用例之后执行。
- `beforeAll`：在所有测试用例之前执行。
- `afterAll`：在所有测试用例之后执行。
- `onTestFailure`：在测试用例失败时执行。
- `onTestFinished`：在测试用例完成时执行。
```js
import { describe, it, expect, beforeEach, afterEach } from 'vitest'

describe('MyComponent', () => {
  let wrapper

  beforeEach(() => {
    wrapper = mount(MyComponent)
  })

  afterEach(() => {
    wrapper.unmount()
  })

  it('renders correctly', () => {
    expect(wrapper.text()).toContain('Hello World')
  })
})
```

> 一般情况下，执行测试的文件名中必须包含 "*test." 或 "*spec." 。


#### Mock

| 方法名 |  描述 |
| --- | --- | 
| vi.fn() | Mock 函数 |
| vi.mock() | Mock 模块 |
| vi.fn() + vi.mock() | Mock 类 |
| mockResolvedValue() | Mock 异步 |
| vi.stubGlobal() | Mock 全局对象 |
| importOriginal() | 部分 Mock |
| vi.clearAllMocks() | 重置 Mock |
|  |  | 

```js
// 1. vi.fn() 函数：
import { vi } from 'vitest';

const mockFn = vi.fn((a, b) => a + b);

test('mock function', () => {
  expect(mockFn(1, 2)).toBe(3); // 调用 mockFn
  expect(mockFn).toHaveBeenCalled(); // 检查是否被调用
  expect(mockFn).toHaveBeenCalledWith(1, 2); // 检查调用参数
});

// 模拟返回值
const mockFn = vi.fn()
  .mockReturnValue(42) // 默认返回值
  .mockReturnValueOnce(10); // 第一次调用返回 10

test('mock return values', () => {
  expect(mockFn()).toBe(10); // 第一次返回 10
  expect(mockFn()).toBe(42); // 之后返回 42
});

// 2.Mock 模块
// fetchData.ts
export const fetchData = async () => {
  const res = await fetch('/api/data');
  return res.json();
};

import { fetchData } from './fetchData';
import { vi } from 'vitest';

// 模拟整个模块
vi.mock('./fetchData', () => ({
  fetchData: vi.fn().mockResolvedValue({ data: 'mocked data' }),
}));

test('fetchData returns mocked data', async () => {
  const data = await fetchData();
  expect(data).toEqual({ data: 'mocked data' });
});


import { fetchData, originalFunction } from './fetchData';

vi.mock('./fetchData', async (importOriginal) => {
  const mod = await importOriginal(); // 获取原始模块
  return {
    ...mod, // 保留其他函数
    fetchData: vi.fn().mockResolvedValue('mocked data'), // 只模拟 fetchData
  };
});

test('partial mock', async () => {
  expect(await fetchData()).toBe('mocked data'); // 模拟的
  expect(originalFunction()).toBe('real implementation'); // 原始的
});


// 3. Mock 类
// UserService.ts
export class UserService {
  getUser(id: number) {
    return fetch(`/api/users/${id}`).then(res => res.json());
  }
}

import { UserService } from './UserService';
import { vi } from 'vitest';

const mockGetUser = vi.fn().mockResolvedValue({ id: 1, name: 'Mocked User' });

vi.mock('./UserService', () => ({
  UserService: vi.fn(() => ({
    getUser: mockGetUser,
  })),
}));

test('mock class method', async () => {
  const userService = new UserService();
  const user = await userService.getUser(1);
  expect(user).toEqual({ id: 1, name: 'Mocked User' });
  expect(mockGetUser).toHaveBeenCalledWith(1);
});


// 4. Mock 第三方库
import axios from 'axios';
import { vi } from 'vitest';

vi.mock('axios');

test('mock axios', async () => {
  const mockedAxios = axios as jest.Mocked<typeof axios>;
  mockedAxios.get.mockResolvedValue({ data: { id: 1 } });

  const res = await axios.get('/api/user');
  expect(res.data).toEqual({ id: 1 });
});


// 5. Mock 全局对象
import { vi } from 'vitest';

const mockLocation = {
  href: 'https://example.com',
  reload: vi.fn(),
};
vi.stubGlobal('location', mockLocation);

test('mock window.location', () => {
  location.reload();
  expect(location.href).toBe('https://example.com');
  expect(location.reload).toHaveBeenCalled();
});


// 6. 重置 Mock
afterEach(() => {
  vi.clearAllMocks(); // 清除调用记录
  vi.restoreAllMocks(); // 恢复原始实现
});
```



#### 编写测试用例

```js
import { mount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'

describe('MyComponent', () => {
  it('renders correctly', () => {
    const wrapper = mount(MyComponent)
    // 断言测试结果
    expect(wrapper.text()).toContain('Hello World')
  })
}
```

!['测试结果'](https://kodo.huixiangwuyou.com/blog/images/web/40413155314.png)

> 支持源码内联测试
```js
// the implementation
export function add(...args: number[]) {
  return args.reduce((a, b) => a + b, 0)
}

// 源码内的测试套件
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest
  it('add', () => {
    expect(add()).toBe(0)
    expect(add(1)).toBe(1)
    expect(add(1, 2, 3)).toBe(6)
  })
}

// 更新 Vitest 配置文件内的 includeSource 以获取到 src/ 下的文件： [文档](https://cn.vitest.dev/guide/in-source.html)
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    includeSource: ['src/**/*.{js,ts}'], 
  },
})
```

#### 创建快照
```js
import { mount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'

describe('MyComponent', () => {
  it('renders correctly', () => {
    const wrapper = mount(MyComponent)
    // 断言测试结果
    expect(wrapper.html()).toMatchSnapshot()
  })  
})

// 运行文件后会在当前测试文件的同级目录下生成 __snapshots__ 目录，里面存放着快照文件，快照文件的名称为测试用例的名称加上 .snap 后缀。
// 可以通过在命令行中运行 vitest --updateSnapshot 来更新快照文件。
```

#### 测试覆盖率
```js
// 运行文件后会在当前测试文件的同级目录下生成 __coverage__ 目录，里面存放着覆盖率报告。
// 可以通过在命令行中运行 vitest --coverage 来生成覆盖率报告。
``` 
![测试覆盖率](https://kodo.huixiangwuyou.com/blog/images/web/4413165654.png)






